Homeโ€บauto-artikel
DRAFT

auto-artikel

JSON

๐Ÿ’ก Ide & Konsep

Sistem Artikel Otomatis (Zadv) โ€” temuan & konsep

Status: HIDUP TAPI BERHENTI MENGHASILKAN. Infra, kode, dan DB sehat. Yang menghalangi

satu kecocokan jam antara crontab dan gate di skrip. Sejak 3 November 2026 tidak ada

artikel baru; per 11 November = 8 hari kosong.

Keadaan hari ini

| Hal | Nilai |

|---|---|

| Aplikasi Zadv | zpromo.zomet.my.id โ€” /api/health โ†’ 200 {"ok":true,"service":"zadv"} |

| Crontab | 0 0 * * * โ†’ docker exec af7wit npx tsx scripts/artikel-harian.ts |

| Gate jam di skrip | artikel_jam default 07:00 |

| Tabel Pengaturan | 0 baris (semua setelan memakai default kode) |

| Artikel published | 66 ยท terakhir 3 Nov 07:25 |

| scheduled menumpuk | 12 (19 Okt: 3 ยท 4 Nov: 4 ยท 6 Nov: 5) |

| draft menggantung | 3 (15 Okt, tanpa scheduledFor) |

| Commit artikel di zomet-main | terakhir 3 Nov |

Akar masalahnya

crontab   jalankan 00:00 WIB
gate      if (nowWib.getUTCHours() < 7) return "SKIP"

crontab jalankan 00:00 WIB

gate if (nowWib.getUTCHours() < 7) return "SKIP"


Jam 00 memang lebih kecil dari 07 โ†’ **setiap malam cron menolak dirinya sendiri sebelum
menyentuh apa pun.** Log membuktikan, delapan baris terakhir berturut-turut:

Jam 00 memang lebih kecil dari 07 โ†’ **setiap malam cron menolak dirinya sendiri sebelum

menyentuh apa pun.** Log membuktikan, delapan baris terakhir berturut-turut:

SKIP: belum waktunya (setting=07:00, now WIB=00:00)

SKIP: belum waktunya (setting=07:00, now WIB=00:00)


Jam terbit terakhir `07:25`, bukan `07:00` tepat โ€” itu tanda artikel terakhir lahir dari
**trigger manual**, bukan cron. Jadi sejak cron dipindah ke VPS, jamnya tidak pernah cocok.

Bukan salah satu pihak saja: crontab menyala tengah malam, skrip menunggu pukul tujuh, dan
karena keduanya disimpan di tempat berbeda (crontab sistem vs `Pengaturan` DB), tidak ada
yang memberi tahu saat keduanya berselisih.

## Yang SUDAH benar (jangan diutak-atik)

Diperiksa langsung di container yang berjalan, bukan dari repo:

Jam terbit terakhir 07:25, bukan 07:00 tepat โ€” itu tanda artikel terakhir lahir dari

trigger manual, bukan cron. Jadi sejak cron dipindah ke VPS, jamnya tidak pernah cocok.

Bukan salah satu pihak saja: crontab menyala tengah malam, skrip menunggu pukul tujuh, dan

karena keduanya disimpan di tempat berbeda (crontab sistem vs Pengaturan DB), tidak ada

yang memberi tahu saat keduanya berselisih.

Yang SUDAH benar (jangan diutak-atik)

Diperiksa langsung di container yang berjalan, bukan dari repo:

scheduledFor: { lte: todayEnd }          // catch-up backlog hari terlewat
orderBy: { scheduledFor: 'asc' }         // slot terlama dulu
const doneApps = new Set<number>()       // maks 1 artikel per app per run
guard per slot (appId + scheduledFor)    // tidak terbit dobel

scheduledFor: { lte: todayEnd } // catch-up backlog hari terlewat

orderBy: { scheduledFor: 'asc' } // slot terlama dulu

const doneApps = new Set<number>() // maks 1 artikel per app per run

guard per slot (appId + scheduledFor) // tidak terbit dobel


Keempatnya sudah terpasang. Artinya 12 `scheduled` yang menumpuk **akan mengalir sendiri**
begitu gate-nya lolos โ€” sekitar 5 hari pada 5 app, tidak perlu dipublish manual.

Generate menarget `today + 3`; publish mengambil `scheduledFor <= akhir hari ini`. Selisih
itu memang sengaja: artikel disiapkan tiga hari lebih awal, lalu terbit sesuai jadwalnya.

## Dua konsep perbaikan

### A. Selaraskan crontab (satu perintah, tanpa deploy)

Keempatnya sudah terpasang. Artinya 12 scheduled yang menumpuk akan mengalir sendiri

begitu gate-nya lolos โ€” sekitar 5 hari pada 5 app, tidak perlu dipublish manual.

Generate menarget today + 3; publish mengambil scheduledFor <= akhir hari ini. Selisih

itu memang sengaja: artikel disiapkan tiga hari lebih awal, lalu terbit sesuai jadwalnya.

Dua konsep perbaikan

A. Selaraskan crontab (satu perintah, tanpa deploy)

0 0 * * *   โ†’   0 7 * * *

0 0 * * * โ†’ 0 7 * * *


Cocok dengan default `artikel_jam = 07:00`. Sekali jalan, langsung hidup.

**Kelemahan:** kalau jam diubah dari dashboard jadi `08:00`, sistem mati lagi dengan cara
yang persis sama โ€” dan tidak ada yang memberi tahu sampai artikel berhenti muncul.
Ini menambal gejalanya, bukan sebabnya.

### B. Gate mengikuti setelan, bukan menunggu (REKOMENDASI)

Cocok dengan default artikel_jam = 07:00. Sekali jalan, langsung hidup.

Kelemahan: kalau jam diubah dari dashboard jadi 08:00, sistem mati lagi dengan cara

yang persis sama โ€” dan tidak ada yang memberi tahu sampai artikel berhenti muncul.

Ini menambal gejalanya, bukan sebabnya.

B. Gate mengikuti setelan, bukan menunggu (REKOMENDASI)

crontab   0 * * * *                                   (mengetuk tiap jam)
gate      nowWib.getUTCHours() !== settingHour        (bukan <)

crontab 0 * * * * (mengetuk tiap jam)

gate nowWib.getUTCHours() !== settingHour (bukan <)


Cron mengetuk tiap jam; hanya jam yang sama dengan setelan dashboard yang bekerja.
Ubah jam dari dashboard sesukanya, tidak ada yang perlu disentuh lagi.

Kenapa aman: ketukan yang tidak kena jam langsung `return` sebelum Gemini dipanggil, dan
guard idempoten (`doneApps`, dedup per slot) membuat ketukan berulang murah โ€” tidak ada
artikel dobel, tidak ada biaya API.

**Konsekuensi yang harus diterima:** cron berdetak 24ร— sehari. Itu memang tujuannya โ€”
satu sumber kebenaran (dashboard) dan cron hanya pemicu bodoh yang tidak perlu tahu jam.

## Tindakan lain yang perlu, terlepas dari A/B

### 1. Lima app dicentang aktif tapi tidak punya URL

Cron mengetuk tiap jam; hanya jam yang sama dengan setelan dashboard yang bekerja.

Ubah jam dari dashboard sesukanya, tidak ada yang perlu disentuh lagi.

Kenapa aman: ketukan yang tidak kena jam langsung return sebelum Gemini dipanggil, dan

guard idempoten (doneApps, dedup per slot) membuat ketukan berulang murah โ€” tidak ada

artikel dobel, tidak ada biaya API.

Konsekuensi yang harus diterima: cron berdetak 24ร— sehari. Itu memang tujuannya โ€”

satu sumber kebenaran (dashboard) dan cron hanya pemicu bodoh yang tidak perlu tahu jam.

Tindakan lain yang perlu, terlepas dari A/B

1. Lima app dicentang aktif tapi tidak punya URL

KOSONG : ZGold ยท ZBengkel ยท ZBilliar ยท Z-Resto ยท Z-Absen   (artikelAktif = true)
JALAN  : Z1 Pos ยท ZXGym ยท Z-Rooms ยท ZX Parenting ยท ZX Pajak

KOSONG : ZGold ยท ZBengkel ยท ZBilliar ยท Z-Resto ยท Z-Absen (artikelAktif = true)

JALAN : Z1 Pos ยท ZXGym ยท Z-Rooms ยท ZX Parenting ยท ZX Pajak


Query generate menyaring `url: { not: null }`, jadi kelimanya dilewati **diam-diam**.
Dashboard menampilkan 10 app aktif, yang benar-benar bekerja 5. Isi URL-nya, atau hapus
centangnya supaya setelan tidak menipu.

### 2. Tiga draft 15 Oktober menggantung

Tanpa `scheduledFor`, sistem tidak akan pernah menyentuhnya. Buang, atau jadwalkan manual.

### 3. Sebaran artikel tidak rata

Query generate menyaring url: { not: null }, jadi kelimanya dilewati diam-diam.

Dashboard menampilkan 10 app aktif, yang benar-benar bekerja 5. Isi URL-nya, atau hapus

centangnya supaya setelan tidak menipu.

2. Tiga draft 15 Oktober menggantung

Tanpa scheduledFor, sistem tidak akan pernah menyentuhnya. Buang, atau jadwalkan manual.

3. Sebaran artikel tidak rata

Z1 Pos 15 ยท ZXGym 15 ยท Z-Rooms 15 ยท ZX Parenting 9 ยท ZX Pajak 7
ZGold 1 ยท ZBengkel 1 ยท ZBilliar 1 ยท Z-Resto 1 ยท Z-Absen 1

Z1 Pos 15 ยท ZXGym 15 ยท Z-Rooms 15 ยท ZX Parenting 9 ยท ZX Pajak 7

ZGold 1 ยท ZBengkel 1 ยท ZBilliar 1 ยท Z-Resto 1 ยท Z-Absen 1


Angka 1 itu sisa seed awal, bukan hasil kerja sistem.

## Cara memverifikasi (jangan percaya satu sumber)

Tiga sumber harus sepakat โ€” halaman artikel yang tetap tampil **bukan** bukti cron jalan,
karena UI menyajikan data lama:

Angka 1 itu sisa seed awal, bukan hasil kerja sistem.

Cara memverifikasi (jangan percaya satu sumber)

Tiga sumber harus sepakat โ€” halaman artikel yang tetap tampil bukan bukti cron jalan,

karena UI menyajikan data lama:

1. DB Zadv
   SELECT status, count(*) FROM "ArtikelDraft" GROUP BY 1;
   SELECT max("publishedAt") FROM "ArtikelDraft";
   SELECT count(*) FILTER (WHERE status='scheduled' AND "scheduledFor" < current_date)
     FROM "ArtikelDraft";          -- backlog yang menumpuk

2. Log cron
   grep SELESAI /tmp/artikel-harian.log | tail -3
   โ†’ "SELESAI: published=N, generated=M, failed=0" adalah satu-satunya tanda cron benar
     bekerja. Tanpa baris ini, jangan sebut cron hidup.

3. Repo zomet-main
   git log origin/main --oneline | grep 'artikel scheduled'

1. DB Zadv

SELECT status, count(*) FROM "ArtikelDraft" GROUP BY 1;

SELECT max("publishedAt") FROM "ArtikelDraft";

SELECT count(*) FILTER (WHERE status='scheduled' AND "scheduledFor" < current_date)

FROM "ArtikelDraft"; -- backlog yang menumpuk

2. Log cron

grep SELESAI /tmp/artikel-harian.log | tail -3

โ†’ "SELESAI: published=N, generated=M, failed=0" adalah satu-satunya tanda cron benar

bekerja. Tanpa baris ini, jangan sebut cron hidup.

3. Repo zomet-main

git log origin/main --oneline | grep 'artikel scheduled'


## Pitfall yang sudah pernah menggigit

| Gejala | Sebab | Catatan |
|---|---|---|
| Log penuh `permission denied /var/run/docker.sock`, tanpa satu pun `SELESAI` | user `djvpri` dulu tidak ada di grup `docker` | **sudah beres** โ€” `groups djvpri` kini memuat `docker`. Baris itu sisa riwayat, bukan masalah sekarang |
| Log penuh `SKIP: belum waktunya` | crontab 00:00 vs gate 07:00 | **belum beres** โ€” inilah yang dibahas |
| Artikel terbit tapi tidak tampil di homepage konsumen | section memakai `{artikel.length > 0 && ...}`, dan fetch `!res.ok โ†’ []` | 503 Zadv menghapus section tanpa error; cek `/api/artikel/publik?appId=N` dulu |
| `Bad credentials` saat push | token `ZOMET_PUSH_TOKEN` invalid atau tidak punya akses `zomet-main` | token valid tapi kurang izin โ†’ 403, bukan 401 |
| `422 [SHA]: Required` dari Gitea | berkas BARU harus `POST`; `PUT` tanpa sha ditolak | atau token kurang scope tulis |

## Urutan pengerjaan yang saya sarankan

Pitfall yang sudah pernah menggigit

| Gejala | Sebab | Catatan |

|---|---|---|

| Log penuh permission denied /var/run/docker.sock, tanpa satu pun SELESAI | user djvpri dulu tidak ada di grup docker | sudah beres โ€” groups djvpri kini memuat docker. Baris itu sisa riwayat, bukan masalah sekarang |

| Log penuh SKIP: belum waktunya | crontab 00:00 vs gate 07:00 | belum beres โ€” inilah yang dibahas |

| Artikel terbit tapi tidak tampil di homepage konsumen | section memakai {artikel.length > 0 && ...}, dan fetch !res.ok โ†’ [] | 503 Zadv menghapus section tanpa error; cek /api/artikel/publik?appId=N dulu |

| Bad credentials saat push | token ZOMET_PUSH_TOKEN invalid atau tidak punya akses zomet-main | token valid tapi kurang izin โ†’ 403, bukan 401 |

| 422 [SHA]: Required dari Gitea | berkas BARU harus POST; PUT tanpa sha ditolak | atau token kurang scope tulis |

Urutan pengerjaan yang saya sarankan

1. Terapkan konsep B (crontab tiap jam + gate !==)
2. Isi URL lima app kosong, atau hapus centangnya
3. Buang tiga draft 15 Oktober
4. Panggil trigger sekali โ†’ buktikan 12 scheduled mulai mengalir (maks 5/hari)
5. Seminggu kemudian: pastikan max(publishedAt) bergerak tanpa disentuh

1. Terapkan konsep B (crontab tiap jam + gate !==)

2. Isi URL lima app kosong, atau hapus centangnya

3. Buang tiga draft 15 Oktober

4. Panggil trigger sekali โ†’ buktikan 12 scheduled mulai mengalir (maks 5/hari)

5. Seminggu kemudian: pastikan max(publishedAt) bergerak tanpa disentuh


Langkah 4 penting: tanpa itu, perbaikan hanya terlihat sebagai "tidak ada error", bukan
sebagai artikel yang benar-benar terbit.


---

## Status terkini (11 Nov 2026) โ€” sudah diperbaiki

Konsep **B** diterapkan dan terbukti bekerja: crontab jadi `0 * * * *`, gate di skrip
memakai `!==`, dan cron pukul 12:06 menghasilkan `SELESAI: published=5, generated=5, failed=0.`
DB `published` 66 โ†’ 71, `max("publishedAt")` bergerak ke 11 Nov, dan 5 commit
`feat: artikel scheduled - ...` masuk ke `zomet-main`.

Rincian lengkap beserta bukti ada di bagian **9. HASIL**. Sisa pekerjaan: lima app tanpa URL,
tiga draft 15 Oktober, dan alarm yang belum ada.

Langkah 4 penting: tanpa itu, perbaikan hanya terlihat sebagai "tidak ada error", bukan

sebagai artikel yang benar-benar terbit.

---

Status terkini (11 Nov 2026) โ€” sudah diperbaiki

Konsep B diterapkan dan terbukti bekerja: crontab jadi 0 * * * *, gate di skrip

memakai !==, dan cron pukul 12:06 menghasilkan SELESAI: published=5, generated=5, failed=0.

DB published 66 โ†’ 71, max("publishedAt") bergerak ke 11 Nov, dan 5 commit

feat: artikel scheduled - ... masuk ke zomet-main.

Rincian lengkap beserta bukti ada di bagian 9. HASIL. Sisa pekerjaan: lima app tanpa URL,

tiga draft 15 Oktober, dan alarm yang belum ada.

1. Keadaan hari ini (12 Nov 2026)

Dokumentasi disusun dari pemeriksaan langsung pada **crontab user djvpri, container Zadv, dan DB produksi โ€” bukan dari membaca repo. Semua angka diambil 12 November 2026**.

Angka

| Hal | Nilai | Sumber |

|---|---|---|

| Kesehatan Zadv | 200 {"ok":true} | /api/health |

| Crontab artikel | 0 * * * * (user djvpri) | crontab -u djvpri -l |

| Gate jam skrip | jamWib >= settingHour + berhenti bila hari ini sudah terisi | scripts/artikel-harian.ts |

| Artikel published | 75 | ArtikelDraft |

| Terbit terakhir | 10 Nov 2026 23:15 | max("publishedAt") |

| scheduled menunggu | 15 (13โ€“16 Nov) | per tanggal di bawah |

| Terlewat (jadwal lampau) | 0 | dijaga batasAkhirHariWib() |

| Pool foto | 62 | ArtikelFoto |

| App dipromosikan | 8 dari 14 (6 nonaktif) | PromoApp.artikelAktif |

Jadwal terbit berikutnya

13 Nov  scheduled 5
14 Nov  scheduled 8
15 Nov  scheduled 1
16 Nov  scheduled 1

13 Nov scheduled 5

14 Nov scheduled 8

15 Nov scheduled 1

16 Nov scheduled 1


Catatan: 8 artikel pada 14 Nov adalah **8 app berbeda**, bukan 8 artikel untuk satu app. Batas harian berlaku per app.

## Yang berubah sejak 11 Nov

Sistem ini sudah dua kali berhenti tanpa suara. Yang dijaga sekarang bukan hanya "apakah jalan", tetapi tiga hal berbeda: **apakah jalan**, **apakah hasilnya berguna**, dan **apakah kegagalan meninggalkan jejak**.

Catatan: 8 artikel pada 14 Nov adalah 8 app berbeda, bukan 8 artikel untuk satu app. Batas harian berlaku per app.

Yang berubah sejak 11 Nov

Sistem ini sudah dua kali berhenti tanpa suara. Yang dijaga sekarang bukan hanya "apakah jalan", tetapi tiga hal berbeda: apakah jalan, apakah hasilnya berguna, dan apakah kegagalan meninggalkan jejak.

2. Empat bug diperbaiki (12 Nov 2026)

Empat bug ditemukan dan diperbaiki 12 November 2026, semuanya terpasang di container yang berjalan.

1. Backlog yang terlewat tidak pernah terbit

// SEBELUM โ€” batas "hari ini" dihitung zona proses (UTC)
const todayEnd = new Date(todayStart.getTime() + 24 * 3600 * 1000)
scheduledFor: { lte: todayEnd }

// SESUDAH โ€” batas akhir hari WIB
scheduledFor: { lt: batasAkhirHariWib() }

// SEBELUM โ€” batas "hari ini" dihitung zona proses (UTC)

const todayEnd = new Date(todayStart.getTime() + 24 * 3600 * 1000)

scheduledFor: { lte: todayEnd }

// SESUDAH โ€” batas akhir hari WIB

scheduledFor: { lt: batasAkhirHariWib() }


Akibat nyata: tiga draft berjadwal **18 Okt 17:00 WIB** (= 10:00 UTC) dianggap *masa depan* dan macet **25 hari**. Komentar di kode bahkan menjanjikan "backlog hari yang terlewat ikut dikerjakan" โ€” janji itu tidak ditepati kodenya.

## 2. Backlog menumpuk lalu terbit sekaligus

Akibat nyata: tiga draft berjadwal 18 Okt 17:00 WIB (= 10:00 UTC) dianggap *masa depan* dan macet 25 hari. Komentar di kode bahkan menjanjikan "backlog hari yang terlewat ikut dikerjakan" โ€” janji itu tidak ditepati kodenya.

2. Backlog menumpuk lalu terbit sekaligus

13 Nov : 5 artikel
14 Nov : 8 artikel   โ† terlihat seperti spam di /artikel

13 Nov : 5 artikel

14 Nov : 8 artikel โ† terlihat seperti spam di /artikel


Batas lama hanya berlaku **per run**, bukan per hari. Sekarang dihitung dari artikel yang sudah terbit hari ini per app; parameter `tanpaBatasHarian` disediakan untuk kuras manual dari dashboard.

## 3. Satu ketukan gagal menghapus seluruh hari

Batas lama hanya berlaku per run, bukan per hari. Sekarang dihitung dari artikel yang sudah terbit hari ini per app; parameter tanpaBatasHarian disediakan untuk kuras manual dari dashboard.

3. Satu ketukan gagal menghapus seluruh hari

// SEBELUM โ€” sama persis, tanpa percobaan ulang
if (jamWib !== settingHour) return

// SESUDAH โ€” boleh mencoba lagi, berhenti hanya bila hari ini sudah terisi
const lewatJam = jamWib >= settingHour
const sudahHariIni = await prisma.artikelDraft.count({ where: { status: 'published', publishedAt: { gte: awalHariWib() } } })
if (sudahHariIni > 0) return

// SEBELUM โ€” sama persis, tanpa percobaan ulang

if (jamWib !== settingHour) return

// SESUDAH โ€” boleh mencoba lagi, berhenti hanya bila hari ini sudah terisi

const lewatJam = jamWib >= settingHour

const sudahHariIni = await prisma.artikelDraft.count({ where: { status: 'published', publishedAt: { gte: awalHariWib() } } })

if (sudahHariIni > 0) return


Log lama sempat berbunyi `SKIP: jam WIB 08 bukan 07:00` empat kali sehari. Itu bukan perilaku yang salah โ€” tetapi artinya **ketukan 07:00 yang gagal tidak punya cadangan**. Gemini 503 atau VPS sibuk cukup untuk menghapus satu hari penuh.

## 4. Satu balasan Gemini cacat menghapus satu slot

Log lama sempat berbunyi SKIP: jam WIB 08 bukan 07:00 empat kali sehari. Itu bukan perilaku yang salah โ€” tetapi artinya ketukan 07:00 yang gagal tidak punya cadangan. Gemini 503 atau VPS sibuk cukup untuk menghapus satu hari penuh.

4. Satu balasan Gemini cacat menghapus satu slot

โœ• [AcuMedis] Bad escaped character in JSON at position 5283

โœ• [AcuMedis] Bad escaped character in JSON at position 5283


`parseGeminiJson` kini membuang escape tidak sah (`\\x`, `\\u` tanpa 4 digit), lalu **mencoba ulang sekali** dengan prompt yang sama. Pesan galatnya menyertakan cuplikan 120 karakter di sekitar posisi gagal โ€” dulu hanya nomor kolom tanpa isi, sehingga penyebabnya tidak bisa ditebak.

## Angka hasil

parseGeminiJson kini membuang escape tidak sah (\\x, \\u tanpa 4 digit), lalu mencoba ulang sekali dengan prompt yang sama. Pesan galatnya menyertakan cuplikan 120 karakter di sekitar posisi gagal โ€” dulu hanya nomor kolom tanpa isi, sehingga penyebabnya tidak bisa ditebak.

Angka hasil

tsc --noEmit            bersih
test-artikel-fix.test.ts 4 lulus
container Zadv          batasAkhirHariWib โœ“  lewatJam >= โœ“  coba ulang sekali โœ“

tsc --noEmit bersih

test-artikel-fix.test.ts 4 lulus

container Zadv batasAkhirHariWib โœ“ lewatJam >= โœ“ coba ulang sekali โœ“


---

## Bagian yang DIPERTAHANKAN (masih berlaku)

- **Structured data 8 halaman produk** โ€” penanda `Article`/`Product` masih terpasang di `zomet-main`.
- **Kanomik 91 artikel + logo publisher** โ€” canonical dan `logo.png` sudah benar di produksi.
- **Audit isi & putaran angle** โ€” `angleIdx` masih dipakai; dedup per slot menjaga artikel kembar.
- **Cara verifikasi** โ€” lihat bagian berikut.

---

Bagian yang DIPERTAHANKAN (masih berlaku)

  • Structured data 8 halaman produk โ€” penanda Article/Product masih terpasang di zomet-main.
  • Kanomik 91 artikel + logo publisher โ€” canonical dan logo.png sudah benar di produksi.
  • Audit isi & putaran angle โ€” angleIdx masih dipakai; dedup per slot menjaga artikel kembar.
  • Cara verifikasi โ€” lihat bagian berikut.

3. Cron & alur satu ketukan

Cron yang terpasang (user `djvpri`)

0 * * * *     artikel-harian.ts       โ† mengetuk tiap jam, gate di dalam skrip
0 8 * * 1     fakta-produk.ts          โ† panen fakta produk, Senin 08:00
0 */3 * * *   artikel-watchdog.sh      โ† alarm bila >30 jam tanpa artikel baru
0 */4 * * *   disk-watchdog.sh

0 * * * * artikel-harian.ts โ† mengetuk tiap jam, gate di dalam skrip

0 8 * * 1 fakta-produk.ts โ† panen fakta produk, Senin 08:00

0 */3 * * * artikel-watchdog.sh โ† alarm bila >30 jam tanpa artikel baru

0 */4 * * * disk-watchdog.sh


Cron artikel **tidak** di crontab `root` โ€” milik user `djvpri`, alasan historis (akses docker). Kalau `crontab -l` sebagai root terlihat bersih dari artikel, itu **bukan** tanda cron hilang.

## Kenapa mengetuk tiap jam, bukan `0 7 * * *`

Mengetuk tiap jam membuat jam bisa diubah dari dashboard **tanpa menyentuh crontab**, dan memberi percobaan ulang otomatis bila jam pertama gagal. Biayanya nol: ketukan yang tidak lolos gate berhenti sebelum memanggil Gemini.

## Alur satu ketukan

Cron artikel tidak di crontab root โ€” milik user djvpri, alasan historis (akses docker). Kalau crontab -l sebagai root terlihat bersih dari artikel, itu bukan tanda cron hilang.

Kenapa mengetuk tiap jam, bukan `0 7 * * *`

Mengetuk tiap jam membuat jam bisa diubah dari dashboard tanpa menyentuh crontab, dan memberi percobaan ulang otomatis bila jam pertama gagal. Biayanya nol: ketukan yang tidak lolos gate berhenti sebelum memanggil Gemini.

Alur satu ketukan

1. Ambil setelan (artikel_jam, tujuan_zomet, tujuan_app)
2. Gate: jamWib >= settingHour?  hari ini sudah ada artikel?  โ†’ berhenti bila tidak lolos
3. publishScheduled()  โ† kuras yang scheduledFor < besok 00:00 WIB
4. generateSatuArtikel() per app, untuk hari+3
5. Push ke zomet-main via Gitea

1. Ambil setelan (artikel_jam, tujuan_zomet, tujuan_app)

2. Gate: jamWib >= settingHour? hari ini sudah ada artikel? โ†’ berhenti bila tidak lolos

3. publishScheduled() โ† kuras yang scheduledFor < besok 00:00 WIB

4. generateSatuArtikel() per app, untuk hari+3

5. Push ke zomet-main via Gitea


Langkah 3 mendahului 4 dengan sengaja: kalau Gemini mati, artikel yang **sudah ada** tetap terbit.

Langkah 3 mendahului 4 dengan sengaja: kalau Gemini mati, artikel yang sudah ada tetap terbit.

4. App yang dipromosikan

Delapan app dipromosikan; enam dimatikan (artikelAktif=false) tanpa dihapus โ€” datanya utuh untuk dihidupkan lagi.

AKTIF   ZX Pajak Kendaraan Kalbar ยท ZX Parenting ยท ZXGym ยท Z1 Pos
        Z-Rooms ยท ZX Carwash ยท YTVault ยท AcuMedis

MATI    ZGold ยท ZBengkel ยท ZBilliar ยท Z-Resto ยท Z-Absen
        (domain curl 000, /produk/<slug> 404)

BUKAN   Zomet (artikelAktif=false, induk situs โ€” bukan produk)

AKTIF ZX Pajak Kendaraan Kalbar ยท ZX Parenting ยท ZXGym ยท Z1 Pos

Z-Rooms ยท ZX Carwash ยท YTVault ยท AcuMedis

MATI ZGold ยท ZBengkel ยท ZBilliar ยท Z-Resto ยท Z-Absen

(domain curl 000, /produk/<slug> 404)

BUKAN Zomet (artikelAktif=false, induk situs โ€” bukan produk)


## Syarat app boleh dipromosikan

Syarat app boleh dipromosikan

1. PromoApp.url hidup (curl 200)
2. PromoApp.halamanProduk ada dan tidak 404
3. artikelAktif = true

1. PromoApp.url hidup (curl 200)

2. PromoApp.halamanProduk ada dan tidak 404

3. artikelAktif = true


Kalau salah satu tidak terpenuhi, mesin akan menulis artikel yang menautkan ke halaman mati โ€” dan itu merusak kepercayaan pembaca sekaligus SEO.

## Cara menghidupkan app yang dimatikan

Kalau salah satu tidak terpenuhi, mesin akan menulis artikel yang menautkan ke halaman mati โ€” dan itu merusak kepercayaan pembaca sekaligus SEO.

Cara menghidupkan app yang dimatikan

1. Nyalakan domain app (DNS + deploy)
2. Pastikan /produk/<slug> hidup di zomet-main
3. PromoApp: isi url + halamanProduk, set artikelAktif = true

1. Nyalakan domain app (DNS + deploy)

2. Pastikan /produk/<slug> hidup di zomet-main

3. PromoApp: isi url + halamanProduk, set artikelAktif = true

5. Tindakan yang masih perlu

| Gejala | Sebab | Status |

|---|---|---|

| Log penuh permission denied /var/run/docker.sock, tanpa satu pun SELESAI | user djvpri dulu tidak ada di grup docker; cron jalan sebagai djvpri | tetap waspada โ€” cron artikel memang milik user djvpri |

| SKIP: jam WIB 08 bukan 07:00 berulang | gate !== lama; ketukan lain tidak punya fungsi apa pun | diperbaiki 12 Nov โ†’ >= + cek "hari ini sudah terisi" |

| Tiga draft macet 25 hari padahal cron jalan | batas todayEnd dihitung UTC | diperbaiki 12 Nov โ†’ batasAkhirHariWib() |

| Artikel terbit 7โ€“8 sehari | batas hanya per run | diperbaiki 12 Nov โ†’ batas per app per hari |

| Bad escaped character in JSON untuk satu app | balasan Gemini cacat, skrip menyerah | diperbaiki 12 Nov โ†’ bersihkan + coba ulang sekali |

| Artikel kembar di slot yang sama | Math.random() untuk angle + tidak ada dedup | diperbaiki 11 Nov โ†’ angleIdx putaran + guard slot |

| Halaman /produk/<slug> 404 padahal app aktif | lima app domain mati | diperbaiki 11 Nov โ†’ artikelAktif=false |

| Deploy Zadv tetap in_progress di panel | bug pencatatan Coolify; log berakhir Rolling update completed | periksa container, jangan langsung deploy ulang |

Pelajaran yang berlaku umum

Setiap gate waktu harus punya SATU sumber kebenaran, atau
punya alarm saat dua sumber itu berselisih.

Kegagalan yang keluar dengan exit 0 tidak akan pernah diberitakan
oleh cron, sistem monitoring, maupun log โ€” yang terlihat hanya
"tidak ada artikel", bukan "cron menolak jalan".

Setiap gate waktu harus punya SATU sumber kebenaran, atau

punya alarm saat dua sumber itu berselisih.

Kegagalan yang keluar dengan exit 0 tidak akan pernah diberitakan

oleh cron, sistem monitoring, maupun log โ€” yang terlihat hanya

"tidak ada artikel", bukan "cron menolak jalan".

6. Cara memverifikasi

Halaman artikel konsumen tetap tampil normal walau publisher mati berhari-hari (disajikan dari data lama). Jadi "halaman hidup" bukan bukti job jalan. Tiga sumber harus sepakat.

1. Log cron

tail -30 /tmp/artikel-harian.log

tail -30 /tmp/artikel-harian.log


Yang dicari: baris `SELESAI: published=N`. Kalau hanya ada `SKIP`, gate menolak โ€” dan itu **bukan** tanda sehat. Perhatikan juga apakah `published=0` padahal `generated>0` (artikel dibuat tetapi tidak terbit).

## 2. DB Zadv

Yang dicari: baris SELESAI: published=N. Kalau hanya ada SKIP, gate menolak โ€” dan itu bukan tanda sehat. Perhatikan juga apakah published=0 padahal generated>0 (artikel dibuat tetapi tidak terbit).

2. DB Zadv

select status, count(*) from "ArtikelDraft" group by status;
select count(*) from "ArtikelDraft" where status='scheduled' and "scheduledFor" < now() - interval '1 day';

select status, count(*) from "ArtikelDraft" group by status;

select count(*) from "ArtikelDraft" where status='scheduled' and "scheduledFor" < now() - interval '1 day';


Angka kedua harus **0**. Kalau tidak nol, backlog macet lagi โ€” periksa `batasAkhirHariWib()` masih dipakai.

## 3. Isi yang benar-benar terbit

Angka kedua harus 0. Kalau tidak nol, backlog macet lagi โ€” periksa batasAkhirHariWib() masih dipakai.

3. Isi yang benar-benar terbit

select d.judul, d."scheduledFor", d."publishedAt", a.nama
from "ArtikelDraft" d join "PromoApp" a on a.id=d."appId"
where d.status='published' order by d."publishedAt" desc limit 5;

select d.judul, d."scheduledFor", d."publishedAt", a.nama

from "ArtikelDraft" d join "PromoApp" a on a.id=d."appId"

where d.status='published' order by d."publishedAt" desc limit 5;


Cek: `appId` menunjuk app yang **punya halaman produk**, dan `publishedAt` hari ini.

## 4. Watchdog

`/root/artikel-watchdog.sh` (cron `0 */3 * * *`) mengirim WA bila **lebih dari 30 jam tidak ada artikel baru**, sekali per 12 jam. Alarm berbasis "tidak ada artikel baru", bukan "ada backlog" โ€” saat menguras 12 slot tertunda, alarm backlog akan berbunyi palsu berhari-hari.

Cek: appId menunjuk app yang punya halaman produk, dan publishedAt hari ini.

4. Watchdog

/root/artikel-watchdog.sh (cron 0 */3 * * *) mengirim WA bila lebih dari 30 jam tidak ada artikel baru, sekali per 12 jam. Alarm berbasis "tidak ada artikel baru", bukan "ada backlog" โ€” saat menguras 12 slot tertunda, alarm backlog akan berbunyi palsu berhari-hari.

7. Riwayat pekerjaan

11 November 2026

crontab artikel       0 0 * * *  โ†’  0 * * * *        (user djvpri)
gate jam              getUTCHours() < settingHour  โ†’  !== settingHour
5 app domain mati     artikelAktif = false (tidak dihapus)
3 app hidup ditambah  ZX Carwash ยท YTVault ยท AcuMedis
angle                 Math.random() โ†’ putaran + kolom angleIdx
tautan internal       halamanProduk + UTM utm_source=artikel&utm_medium=blog
panjang artikel       ditolak bila < 550 kata
sitemap + robots      dibuat di zomet-main (98 URL)
pool foto             26 โ†’ 62
watchdog              >30 jam tanpa artikel baru โ†’ WA
structured data       Article/Product di 8 halaman produk

crontab artikel 0 0 * * * โ†’ 0 * * * * (user djvpri)

gate jam getUTCHours() < settingHour โ†’ !== settingHour

5 app domain mati artikelAktif = false (tidak dihapus)

3 app hidup ditambah ZX Carwash ยท YTVault ยท AcuMedis

angle Math.random() โ†’ putaran + kolom angleIdx

tautan internal halamanProduk + UTM utm_source=artikel&utm_medium=blog

panjang artikel ditolak bila < 550 kata

sitemap + robots dibuat di zomet-main (98 URL)

pool foto 26 โ†’ 62

watchdog >30 jam tanpa artikel baru โ†’ WA

structured data Article/Product di 8 halaman produk


## 12 November 2026

12 November 2026

batas backlog         todayEnd (UTC) โ†’ batasAkhirHariWib()
batas harian          per run โ†’ per app per hari
gate jam              !== โ†’ >= + berhenti bila hari ini terisi
balasan Gemini cacat  dibersihkan + coba ulang sekali + pesan bergalat cuplikan
3 draft duplikat      dihapus (backup: /root/backup-artikeldraft-20261211-1234.sql)

batas backlog todayEnd (UTC) โ†’ batasAkhirHariWib()

batas harian per run โ†’ per app per hari

gate jam !== โ†’ >= + berhenti bila hari ini terisi

balasan Gemini cacat dibersihkan + coba ulang sekali + pesan bergalat cuplikan

3 draft duplikat dihapus (backup: /root/backup-artikeldraft-20261211-1234.sql)


Yang **belum** dikerjakan dan masih terbuka:

Yang belum dikerjakan dan masih terbuka:

pool ArtikelIde masih kosong โ†’ ide dari pertanyaan pelanggan WA belum terpakai
WaRiwayat 0 baris
batas "1 artikel per hari untuk SELURUH situs" (sekarang per app)

pool ArtikelIde masih kosong โ†’ ide dari pertanyaan pelanggan WA belum terpakai

WaRiwayat 0 baris

batas "1 artikel per hari untuk SELURUH situs" (sekarang per app)